Micron Document
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| SparkN0de-git | SparkN0de |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Commit c66d290db72f69fc03a532665f9d05d3671d76c5


Parents : 7ea2ad3
Author : Ivan <ivan@quad4.io>
Signature : Signature validation error
Date : 2026-04-22T20:10:55-05:00

feat(sieve-filters): implement Sieve Filters page and integrate notification suppression logic

Changes
Diff

diff --git a/meshchatx/src/frontend/components/App.vue b/meshchatx/src/frontend/components/App.vue
index 5a5d5205..43b23dfd 100644
--- a/meshchatx/src/frontend/components/App.vue
+++ b/meshchatx/src/frontend/components/App.vue
@@ -1119,6 +1119,9 @@ export default {
if (this.config?.do_not_disturb_enabled) {
break;
}
+ if (json.sieve_suppress_notifications) {
+ break;
+ }
// show notification for new messages if window is not focussed
// only for incoming messages from people (with content)

diff --git a/meshchatx/src/frontend/components/tools/SieveFiltersPage.vue b/meshchatx/src/frontend/components/tools/SieveFiltersPage.vue
new file mode 100644
index 00000000..437d0b22
--- /dev/null
+++ b/meshchatx/src/frontend/components/tools/SieveFiltersPage.vue
@@ -0,0 +1,447 @@
+<!-- SPDX-License-Identifier: 0BSD AND MIT -->
+
+<template>
+ <div class="flex flex-col flex-1 overflow-hidden min-w-0 bg-slate-50 dark:bg-zinc-950">
+ <div class="flex-1 overflow-y-auto w-full pb-[max(1rem,env(safe-area-inset-bottom))]">
+ <div class="p-3 sm:p-4 md:p-6 max-w-6xl mx-auto w-full space-y-4 min-w-0">
+ <div
+ class="flex flex-wrap items-start justify-between gap-3 border-b border-gray-200 dark:border-zinc-800 pb-4"
+ >
+ <div class="flex items-start gap-3 min-w-0">
+ <div
+ class="p-2 bg-violet-100 dark:bg-violet-900/30 text-violet-600 dark:text-violet-300 rounded-lg shrink-0"
+ >
+ <MaterialDesignIcon icon-name="filter-variant" class="size-6" />
+ </div>
+ <div class="min-w-0">
+ <div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
+ {{ $t("tools.utilities") }}
+ </div>
+ <h1 class="text-lg sm:text-xl font-bold text-gray-900 dark:text-white tracking-tight">
+ {{ $t("tools.sieve_filters.title") }}
+ </h1>
+ <p class="text-xs sm:text-sm text-gray-600 dark:text-gray-400 mt-1 max-w-2xl">
+ {{ $t("tools.sieve_filters.subtitle") }}
+ </p>
+ </div>
+ </div>
+ <RouterLink
+ to="/tools"
+ class="inline-flex items-center gap-2 text-sm text-violet-600 dark:text-violet-300 hover:underline shrink-0"
+ >
+ <MaterialDesignIcon icon-name="arrow-left" class="size-4" />
+ {{ $t("tools.sieve_filters.back_tools") }}
+ </RouterLink>
+ </div>
+
+ <div class="grid grid-cols-1 xl:grid-cols-2 gap-4 items-start">
+ <div class="space-y-4 min-w-0 order-2 xl:order-1">
+ <div
+ class="rounded-xl border border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-950 p-4 space-y-3"
+ >
+ <div class="flex items-center justify-between gap-2">
+ <h2 class="text-base font-semibold text-gray-900 dark:text-white">
+ {{ $t("tools.sieve_filters.rules_heading") }}
+ </h2>
+ <button
+ type="button"
+ class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium bg-violet-600 text-white hover:bg-violet-700 transition-colors"
+ @click="addRule"
+ >
+ <MaterialDesignIcon icon-name="plus" class="size-4" />
+ {{ $t("tools.sieve_filters.add_rule") }}
+ </button>
+ </div>
+ <p class="text-xs text-gray-500 dark:text-gray-400">
+ {{ $t("tools.sieve_filters.order_hint") }}
+ </p>
+
+ <div
+ v-if="filters.length === 0"
+ class="text-sm text-gray-500 dark:text-gray-400 py-6 text-center"
+ >
+ {{ $t("tools.sieve_filters.empty_rules") }}
+ </div>
+
+ <div v-else class="space-y-3">
+ <div
+ v-for="(rule, index) in filters"
+ :key="rule.id"
+ class="rounded-lg border border-gray-200 dark:border-zinc-800 p-3 space-y-3 bg-gray-50/80 dark:bg-zinc-900/40"
+ >
+ <div class="flex flex-wrap items-center justify-between gap-2">
+ <label
+ class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-200"
+ >
+ <input
+ v-model="rule.enabled"
+ type="checkbox"
+ class="rounded border-gray-300"
+ />
+ {{ $t("tools.sieve_filters.enabled") }}
+ </label>
+ <div class="flex items-center gap-1">
+ <button
+ type="button"
+ class="p-1.5 rounded-lg text-gray-500 hover:bg-gray-200 dark:hover:bg-zinc-800"
+ :title="$t('tools.sieve_filters.move_up')"
+ :disabled="index === 0"
+ @click="moveRule(index, -1)"
+ >
+ <MaterialDesignIcon icon-name="chevron-up" class="size-5" />
+ </button>
+ <button
+ type="button"
+ class="p-1.5 rounded-lg text-gray-500 hover:bg-gray-200 dark:hover:bg-zinc-800"
+ :title="$t('tools.sieve_filters.move_down')"
+ :disabled="index === filters.length - 1"
+ @click="moveRule(index, 1)"
+ >
+ <MaterialDesignIcon icon-name="chevron-down" class="size-5" />
+ </button>
+ <button
+ type="button"
+ class="p-1.5 rounded-lg text-red-600 hover:bg-red-50 dark:hover:bg-red-950/40"
+ :title="$t('tools.sieve_filters.remove_rule')"
+ @click="removeRule(index)"
+ >
+ <MaterialDesignIcon icon-name="delete-outline" class="size-5" />
+ </button>
+ </div>
+ </div>
+ <div>
+ <label
+ class="block text-[10px] font-bold text-gray-400 dark:text-zinc-500 uppercase tracking-widest mb-1"
+ >{{ $t("tools.sieve_filters.terms_label") }}</label
+ >
+ <textarea
+ :value="termsText(rule)"
+ rows="3"
+ class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 text-sm text-gray-900 dark:text-white font-mono"
+ :placeholder="$t('tools.sieve_filters.terms_placeholder')"
+ @input="onTermsInput(rule, $event)"
+ />
+ </div>
+ <div>
+ <label
+ class="block text-[10px] font-bold text-gray-400 dark:text-zinc-500 uppercase tracking-widest mb-1"
+ >{{ $t("tools.sieve_filters.scope_label") }}</label
+ >
+ <select
+ v-model="rule.scope"
+ class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 text-sm text-gray-900 dark:text-white"
+ >
+ <option value="everyone">
+ {{ $t("tools.sieve_filters.scope_everyone") }}
+ </option>
+ <option value="contacts">
+ {{ $t("tools.sieve_filters.scope_contacts") }}
+ </option>
+ <option value="non_contacts">
+ {{ $t("tools.sieve_filters.scope_non_contacts") }}
+ </option>
+ </select>
+ </div>
+ <div class="space-y-2">
+ <div
+ class="text-[10px] font-bold text-gray-400 dark:text-zinc-500 uppercase tracking-widest"
+ >
+ {{ $t("tools.sieve_filters.match_targets_label") }}
+ </div>
+ <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-200">
+ <input
+ v-model="rule.match_peer_fields"
+ type="checkbox"
+ class="rounded border-gray-300"
+ @change="onMatchTargetsChange(rule)"
+ />
+ {{ $t("tools.sieve_filters.match_peer_fields") }}
+ </label>
+ <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-200">
+ <input
+ v-model="rule.match_message"
+ type="checkbox"
+ class="rounded border-gray-300"
+ @change="onMatchTargetsChange(rule)"
+ />
+ {{ $t("tools.sieve_filters.match_message") }}
+ </label>
+ <p class="text-xs text-gray-500 dark:text-gray-400">
+ {{ $t("tools.sieve_filters.match_targets_hint") }}
+ </p>
+ </div>
+ <div>
+ <label
+ class="block text-[10px] font-bold text-gray-400 dark:text-zinc-500 uppercase tracking-widest mb-1"
+ >{{ $t("tools.sieve_filters.match_mode_label") }}</label
+ >
+ <select
+ v-model="rule.match_mode"
+ class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 text-sm text-gray-900 dark:text-white"
+ >
+ <option value="substring">
+ {{ $t("tools.sieve_filters.match_mode_substring") }}
+ </option>
+ <option value="regex">
+ {{ $t("tools.sieve_filters.match_mode_regex") }}
+ </option>
+ </select>
+ </div>
+ <div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
+ <div>
+ <label
+ class="block text-[10px] font-bold text-gray-400 dark:text-zinc-500 uppercase tracking-widest mb-1"
+ >{{ $t("tools.sieve_filters.action_label") }}</label
+ >
+ <select
+ v-model="rule.action"
+ class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 text-sm text-gray-900 dark:text-white"
+ @change="onActionChange(rule)"
+ >
+ <option value="hide">
+ {{ $t("tools.sieve_filters.action_hide") }}
+ </option>
+ <option value="ignore">
+ {{ $t("tools.sieve_filters.action_ignore") }}
+ </option>
+ <option value="folder">
+ {{ $t("tools.sieve_filters.action_folder") }}
+ </option>
+ <option value="banish">
+ {{ $t("tools.sieve_filters.action_banish") }}
+ </option>
+ </select>
+ </div>
+ <div v-if="rule.action === 'folder'">
+ <label
+ class="block text-[10px] font-bold text-gray-400 dark:text-zinc-500 uppercase tracking-widest mb-1"
+ >{{ $t("tools.sieve_filters.folder_label") }}</label
+ >
+ <select
+ v-model.number="rule.folder_id"
+ class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 text-sm text-gray-900 dark:text-white"
+ >
+ <option v-for="f in folders" :key="f.id" :value="f.id">
+ {{ f.name }}
+ </option>
+ </select>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div class="flex flex-wrap gap-2 pt-2">
+ <button
+ type="button"
+ class="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium bg-gray-900 text-white dark:bg-zinc-100 dark:text-zinc-900 hover:opacity-90"
+ :disabled="isSaving"
+ @click="save"
+ >
+ <MaterialDesignIcon
+ v-if="!isSaving"
+ icon-name="content-save-outline"
+ class="size-4"
+ />
+ <span v-if="isSaving">{{ $t("tools.sieve_filters.saving") }}</span>
+ <span v-else>{{ $t("tools.sieve_filters.save") }}</span>
+ </button>
+ <button
+ type="button"
+ class="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium border border-gray-300 dark:border-zinc-600 text-gray-800 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-zinc-800"
+ :disabled="isSaving"
+ @click="reload"
+ >
+ <MaterialDesignIcon icon-name="restore" class="size-4" />
+ {{ $t("tools.sieve_filters.revert") }}
+ </button>
+ </div>
+ </div>
+ </div>
+
+ <div class="min-w-0 order-1 xl:order-2 space-y-2">
+ <h2 class="text-base font-semibold text-gray-900 dark:text-white px-1">
+ {{ $t("tools.sieve_filters.flow_heading") }}
+ </h2>
+ <SieveFlowNetwork :filters="filters" :folders="folders" :labels="flowLabels" />
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script>
+import MaterialDesignIcon from "../MaterialDesignIcon.vue";
+import SieveFlowNetwork from "./internal/SieveFlowNetwork.vue";
+import ToastUtils from "../../js/ToastUtils";
+
+function newRuleId() {
+ if (typeof crypto !== "undefined" && crypto.randomUUID) {
+ return crypto.randomUUID();
+ }
+ return `r-${Date.now()}-${Math.random().toString(16).slice(2)}`;
+}
+
+export default {
+ name: "SieveFiltersPage",
+ components: {
+ MaterialDesignIcon,
+ SieveFlowNetwork,
+ },
+ data() {
+ return {
+ filters: [],
+ folders: [],
+ isSaving: false,
+ };
+ },
+ computed: {
+ flowLabels() {
+ return {
+ sourceNode: this.$t("tools.sieve_filters.flow_source"),
+ sourceHint: this.$t("tools.sieve_filters.flow_source_hint"),
+ rulePrefix: this.$t("tools.sieve_filters.flow_if"),
+ hide: this.$t("tools.sieve_filters.flow_hide"),
+ ignore: this.$t("tools.sieve_filters.action_ignore"),
+ banish: this.$t("tools.sieve_filters.flow_banish"),
+ folder: this.$t("tools.sieve_filters.flow_folder"),
+ noRules: this.$t("tools.sieve_filters.flow_no_rules"),
+ graphScopeEveryone: this.$t("tools.sieve_filters.graph_scope_everyone"),
+ graphScopeContacts: this.$t("tools.sieve_filters.graph_scope_contacts"),
+ graphScopeNonContacts: this.$t("tools.sieve_filters.graph_scope_non_contacts"),
+ graphMatchPeer: this.$t("tools.sieve_filters.graph_match_peer"),
+ graphMatchMessage: this.$t("tools.sieve_filters.graph_match_message"),
+ graphMatchModeSubstring: this.$t("tools.sieve_filters.graph_match_mode_substring"),
+ graphMatchModeRegex: this.$t("tools.sieve_filters.graph_match_mode_regex"),
+ };
+ },
+ },
+ mounted() {
+ this.reload();
+ },
+ methods: {
+ termsText(rule) {
+ return (rule.terms || []).join("\n");
+ },
+ onTermsInput(rule, e) {
+ const raw = (e.target && e.target.value) || "";
+ rule.terms = raw
+ .split(/[\n,]+/)
+ .map((s) => s.trim())
+ .filter(Boolean);
+ },
+ onActionChange(rule) {
+ if (rule.action === "folder" && this.folders.length && (rule.folder_id == null || rule.folder_id === "")) {
+ rule.folder_id = this.folders[0].id;
+ }
+ if (rule.action !== "folder") {
+ rule.folder_id = null;
+ }
+ },
+ onMatchTargetsChange(rule) {
+ if (!rule.match_peer_fields && !rule.match_message) {
+ rule.match_peer_fields = true;
+ }
+ },
+ mapRuleFromApi(r) {
+ let action = r.action || "ignore";
+ if (action === "block") {
+ action = "hide";
+ }
+ return {
+ id: r.id || newRuleId(),
+ enabled: r.enabled !== false,
+ scope: r.scope === "contacts" || r.scope === "non_contacts" ? r.scope : "everyone",
+ terms: Array.isArray(r.terms) ? [...r.terms] : [],
+ action,
+ folder_id: r.folder_id ?? null,
+ match_peer_fields: r.match_peer_fields !== false,
+ match_message: !!r.match_message,
+ match_mode: r.match_mode === "regex" ? "regex" : "substring",
+ };
+ },
+ addRule() {
+ const base = {
+ id: newRuleId(),
+ enabled: true,
+ scope: "everyone",
+ terms: [],
+ action: "ignore",
+ folder_id: this.folders.length ? this.folders[0].id : null,
+ match_peer_fields: true,
+ match_message: false,
+ match_mode: "substring",
+ };
+ this.filters.push(base);
+ },
+ removeRule(index) {
+ this.filters.splice(index, 1);
+ },
+ moveRule(index, delta) {
+ const j = index + delta;
+ if (j < 0 || j >= this.filters.length) {
+ return;
+ }
+ const copy = this.filters.slice();
+ const t = copy[index];
+ copy[index] = copy[j];
+ copy[j] = t;
+ this.filters = copy;
+ },
+ normalizeForSave() {
+ return this.filters.map((r) => {
+ const scope = r.scope === "contacts" || r.scope === "non_contacts" ? r.scope : "everyone";
+ const match_peer_fields = r.match_peer_fields !== false;
+ const match_message = !!r.match_message;
+ const targets_ok = match_peer_fields || match_message;
+ return {
+ id: r.id,
+ enabled: !!r.enabled,
+ scope,
+ terms: Array.isArray(r.terms) ? r.terms : [],
+ action: r.action === "block" ? "hide" : r.action,
+ folder_id: r.action === "folder" ? r.folder_id : null,
+ match_peer_fields: targets_ok ? match_peer_fields : true,
+ match_message: targets_ok ? match_message : false,
+ match_mode: r.match_mode === "regex" ? "regex" : "substring",
+ };
+ });
+ },
+ async reload() {
+ try {
+ const [fRes, foldersRes] = await Promise.all([
+ window.api.get("/api/v1/lxmf/sieve-filters"),
+ window.api.get("/api/v1/lxmf/folders"),
+ ]);
+ const raw = fRes.data.filters || [];
+ this.filters = raw.map((r) => this.mapRuleFromApi(r));
+ this.folders = foldersRes.data || [];
+ this.onActionChangeForAll();
+ } catch (e) {
+ console.error(e);
+ ToastUtils.error(this.$t("tools.sieve_filters.load_failed"));
+ }
+ },
+ onActionChangeForAll() {
+ this.filters.forEach((r) => this.onActionChange(r));
+ },
+ async save() {
+ this.isSaving = true;
+ try {
+ const payload = { filters: this.normalizeForSave() };
+ const res = await window.api.put("/api/v1/lxmf/sieve-filters", payload);
+ this.filters = (res.data.filters || []).map((r) => this.mapRuleFromApi(r));
+ ToastUtils.success(this.$t("tools.sieve_filters.saved"));
+ } catch (e) {
+ const msg =
+ (e.response && e.response.data && e.response.data.message) ||
+ e.message ||
+ this.$t("tools.sieve_filters.save_failed");
+ ToastUtils.error(msg);
+ } finally {
+ this.isSaving = false;
+ }
+ },
+ },
+};
+</script>

diff --git a/meshchatx/src/frontend/components/tools/ToolsPage.vue b/meshchatx/src/frontend/components/tools/ToolsPage.vue
index fd6f76fb..97d12359 100644
--- a/meshchatx/src/frontend/components/tools/ToolsPage.vue
+++ b/meshchatx/src/frontend/components/tools/ToolsPage.vue
@@ -206,6 +206,14 @@ export default {
titleKey: "tools.forwarder.title",
descriptionKey: "tools.forwarder.description",
},
+ {
+ name: "sieve-filters",
+ route: { name: "sieve-filters" },
+ icon: "filter-variant",
+ iconBg: "tool-card__icon bg-violet-50 text-violet-600 dark:bg-violet-900/30 dark:text-violet-200",
+ titleKey: "tools.sieve_filters.title",
+ descriptionKey: "tools.sieve_filters.description",
+ },
{
name: "documentation",
route: { name: "documentation" },

diff --git a/meshchatx/src/frontend/components/tools/internal/SieveFlowNetwork.vue b/meshchatx/src/frontend/components/tools/internal/SieveFlowNetwork.vue
new file mode 100644
index 00000000..39d52a46
--- /dev/null
+++ b/meshchatx/src/frontend/components/tools/internal/SieveFlowNetwork.vue
@@ -0,0 +1,257 @@
+<!-- SPDX-License-Identifier: 0BSD AND MIT -->
+
+<template>
+ <div
+ ref="host"
+ class="sieve-flow-host rounded-xl border border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-950"
+ />
+</template>
+
+<script>
+import "vis-network/styles/vis-network.css";
+import { DataSet } from "vis-data";
+import { Network } from "vis-network";
+
+export default {
+ name: "SieveFlowNetwork",
+ props: {
+ filters: {
+ type: Array,
+ default: () => [],
+ },
+ folders: {
+ type: Array,
+ default: () => [],
+ },
+ labels: {
+ type: Object,
+ default: () => ({}),
+ },
+ },
+ data() {
+ return {
+ network: null,
+ };
+ },
+ watch: {
+ filters: {
+ deep: true,
+ handler() {
+ this.rebuild();
+ },
+ },
+ folders: {
+ deep: true,
+ handler() {
+ this.rebuild();
+ },
+ },
+ labels: {
+ deep: true,
+ handler() {
+ this.rebuild();
+ },
+ },
+ },
+ mounted() {
+ this.$nextTick(() => this.rebuild());
+ window.addEventListener("resize", this.onResize);
+ },
+ beforeUnmount() {
+ window.removeEventListener("resize", this.onResize);
+ this.destroyNetwork();
+ },
+ methods: {
+ onResize() {
+ this.network?.redraw();
+ this.network?.fit({ animation: false });
+ },
+ destroyNetwork() {
+ if (this.network) {
+ this.network.destroy();
+ this.network = null;
+ }
+ },
+ folderName(folderId) {
+ const f = this.folders.find((x) => x.id === folderId);
+ return f ? f.name : String(folderId);
+ },
+ rebuild() {
+ this.destroyNetwork();
+ const el = this.$refs.host;
+ if (!el) {
+ return;
+ }
+ const L = this.labels || {};
+ const nodes = [];
+ const edges = [];
+ const palette = {
+ src: { background: "#2563eb", border: "#1d4ed8", font: "#ffffff" },
+ rule: { background: "#f4f4f5", border: "#a1a1aa", font: "#18181b" },
+ ruleDark: { background: "#27272a", border: "#52525b", font: "#fafafa" },
+ hide: { background: "#b91c1c", border: "#991b1b", font: "#ffffff" },
+ ignore: { background: "#ca8a04", border: "#a16207", font: "#ffffff" },
+ folder: { background: "#15803d", border: "#166534", font: "#ffffff" },
+ banish: { background: "#4c1d95", border: "#5b21b6", font: "#ffffff" },
+ };
+ const isDark = typeof document !== "undefined" && document.documentElement.classList.contains("dark");
+ const ruleColors = isDark ? palette.ruleDark : palette.rule;
+
+ nodes.push({
+ id: "sieve-src",
+ label: L.sourceNode || "Peers",
+ title: L.sourceHint || "",
+ level: 0,
+ shape: "box",
+ margin: 12,
+ font: { color: palette.src.font, multi: true },
+ color: { background: palette.src.background, border: palette.src.border, highlight: palette.src },
+ });
+
+ const enabled = (this.filters || []).filter((r) => r && r.enabled !== false);
+ const outcomes = new Set();
+
+ enabled.forEach((rule, ruleIndex) => {
+ const rid = `sieve-rule-${rule.id || ruleIndex}`;
+ const sc = rule.scope === "contacts" || rule.scope === "non_contacts" ? rule.scope : "everyone";
+ const scopeLine =
+ sc === "contacts"
+ ? L.graphScopeContacts || "Contacts"
+ : sc === "non_contacts"
+ ? L.graphScopeNonContacts || "Non-contacts"
+ : L.graphScopeEveryone || "Everyone";
+ const terms = (rule.terms || []).slice(0, 4).join(", ");
+ const more = (rule.terms || []).length > 4 ? "…" : "";
+ const matchPeer = rule.match_peer_fields !== false;
+ const matchMsg = !!rule.match_message;
+ const modeLine =
+ rule.match_mode === "regex"
+ ? L.graphMatchModeRegex || "regex"
+ : L.graphMatchModeSubstring || "substring";
+ const targetBits = [];
+ if (matchPeer) {
+ targetBits.push(L.graphMatchPeer || "peer");
+ }
+ if (matchMsg) {
+ targetBits.push(L.graphMatchMessage || "msg");
+ }
+ const targetLine = targetBits.length ? targetBits.join("+") : L.graphMatchPeer || "peer";
+ nodes.push({
+ id: rid,
+ label: `${scopeLine}\n${targetLine} · ${modeLine}\n${L.rulePrefix || "If"}:\n${terms || "…"}${more}`,
+ title: (rule.terms || []).join("\n"),
+ level: 1,
+ shape: "box",
+ margin: 10,
+ font: { color: ruleColors.font, multi: true, size: 13 },
+ color: {
+ background: ruleColors.background,
+ border: ruleColors.border,
+ highlight: ruleColors,
+ },
+ });
+ edges.push({
+ from: "sieve-src",
+ to: rid,
+ arrows: "to",
+ color: { color: "#94a3b8" },
+ });
+
+ let outId = "sieve-out-hide";
+ let outLabel = L.hide || "Hide";
+ let outColor = palette.hide;
+ const act = rule.action === "block" ? "hide" : rule.action;
+ if (act === "ignore") {
+ outId = "sieve-out-ignore";
+ outLabel = L.ignore || "Ignore";
+ outColor = palette.ignore;
+ } else if (act === "banish") {
+ outId = "sieve-out-banish";
+ outLabel = L.banish || "Banish";
+ outColor = palette.banish;
+ } else if (act === "folder" && rule.folder_id != null) {
+ outId = `sieve-out-folder-${rule.folder_id}`;
+ outLabel = `${L.folder || "Folder"}:\n${this.folderName(rule.folder_id)}`;
+ outColor = palette.folder;
+ }
+ outcomes.add(
+ JSON.stringify({
+ id: outId,
+ label: outLabel,
+ bg: outColor.background,
+ bd: outColor.border,
+ fg: outColor.font,
+ })
+ );
+ edges.push({
+ from: rid,
+ to: outId,
+ arrows: "to",
+ color: { color: "#64748b" },
+ });
+ });
+
+ outcomes.forEach((enc) => {
+ const o = JSON.parse(enc);
+ nodes.push({
+ id: o.id,
+ label: o.label,
+ level: 2,
+ shape: "box",
+ margin: 12,
+ font: { color: o.fg, multi: true },
+ color: {
+ background: o.bg,
+ border: o.bd,
+ highlight: { background: o.bg, border: o.bd },
+ },
+ });
+ });
+
+ if (enabled.length === 0) {
+ nodes.push({
+ id: "sieve-out-none",
+ label: L.noRules || "No rules",
+ level: 2,
+ shape: "box",
+ margin: 12,
+ font: { color: "#64748b" },
+ color: { background: "#f1f5f9", border: "#cbd5e1" },
+ });
+ edges.push({
+ from: "sieve-src",
+ to: "sieve-out-none",
+ arrows: "to",
+ color: { color: "#94a3b8" },
+ });
+ }
+
+ const data = { nodes: new DataSet(nodes), edges: new DataSet(edges) };
+ this.network = new Network(el, data, {
+ layout: {
+ hierarchical: {
+ direction: "LR",
+ sortMethod: "directed",
+ levelSeparation: 140,
+ nodeSpacing: 110,
+ },
+ },
+ physics: false,
+ interaction: { hover: true, zoomView: true, dragView: true },
+ edges: { smooth: { type: "cubicBezier", forceDirection: "horizontal" } },
+ });
+ this.network.once("stabilizationIterationsDone", () => {
+ this.network.fit({ animation: false });
+ });
+ },
+ },
+};
+</script>
+
+<style scoped>
+.sieve-flow-host {
+ width: 100%;
+ height: min(420px, 55vh);
+ min-height: 280px;
+}
+</style>

diff --git a/tests/frontend/SieveFiltersPage.test.js b/tests/frontend/SieveFiltersPage.test.js
new file mode 100644
index 00000000..1f896c01
--- /dev/null
+++ b/tests/frontend/SieveFiltersPage.test.js
@@ -0,0 +1,114 @@
+import { mount } from "@vue/test-utils";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import SieveFiltersPage from "@/components/tools/SieveFiltersPage.vue";
+import { createRouter, createWebHistory } from "vue-router";
+import ToastUtils from "../../meshchatx/src/frontend/js/ToastUtils";
+
+vi.mock("../../meshchatx/src/frontend/js/ToastUtils", () => ({
+ default: {
+ success: vi.fn(),
+ error: vi.fn(),
+ warning: vi.fn(),
+ info: vi.fn(),
+ },
+}));
+
+describe("SieveFiltersPage.vue", () => {
+ const router = createRouter({
+ history: createWebHistory(),
+ routes: [{ path: "/tools", name: "tools", component: { template: "<div/>" } }],
+ });
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ global.api.get = vi.fn((url) => {
+ if (url.includes("sieve-filters")) {
+ return Promise.resolve({
+ data: {
+ filters: [
+ {
+ id: "r1",
+ enabled: true,
+ terms: ["spam"],
+ action: "ignore",
+ folder_id: null,
+ },
+ ],
+ },
+ });
+ }
+ if (url.includes("folders")) {
+ return Promise.resolve({ data: [{ id: 1, name: "Inbox" }] });
+ }
+ return Promise.resolve({ data: {} });
+ });
+ global.api.put = vi.fn(() =>
+ Promise.resolve({
+ data: {
+ filters: [
+ {
+ id: "r1",
+ enabled: true,
+ terms: ["spam"],
+ action: "ignore",
+ folder_id: null,
+ },
+ ],
+ },
+ })
+ );
+ window.api = global.api;
+ });
+
+ it("loads filters and folders from the API", async () => {
+ const wrapper = mount(SieveFiltersPage, {
+ global: {
+ plugins: [router],
+ mocks: { $t: (k) => k },
+ stubs: {
+ MaterialDesignIcon: { template: "<span/>", props: ["iconName"] },
+ SieveFlowNetwork: { template: "<div class='sieve-flow-stub'/>" },
+ RouterLink: { template: "<a><slot/></a>", props: ["to"] },
+ },
+ },
+ });
+ await Promise.resolve();
+ await wrapper.vm.$nextTick();
+ await Promise.resolve();
+ expect(global.api.get).toHaveBeenCalled();
+ expect(wrapper.vm.filters.length).toBe(1);
+ expect(wrapper.vm.filters[0].terms).toEqual(["spam"]);
+ expect(wrapper.vm.folders.length).toBe(1);
+ });
+
+ it("saves filters via PUT", async () => {
+ const wrapper = mount(SieveFiltersPage, {
+ global: {
+ plugins: [router],
+ mocks: { $t: (k) => k },
+ stubs: {
+ MaterialDesignIcon: { template: "<span/>", props: ["iconName"] },
+ SieveFlowNetwork: { template: "<div/>" },
+ RouterLink: { template: "<a><slot/></a>", props: ["to"] },
+ },
+ },
+ });
+ await Promise.resolve();
+ await wrapper.vm.$nextTick();
+ await Promise.resolve();
+ await wrapper.vm.save();
+ expect(global.api.put).toHaveBeenCalledWith(
+ "/api/v1/lxmf/sieve-filters",
+ expect.objectContaining({
+ filters: expect.arrayContaining([
+ expect.objectContaining({
+ match_peer_fields: true,
+ match_message: false,
+ match_mode: "substring",
+ }),
+ ]),
+ })
+ );
+ expect(ToastUtils.success).toHaveBeenCalled();
+ });
+});

diff --git a/tests/frontend/ToolsPage.test.js b/tests/frontend/ToolsPage.test.js
index 456cef89..76dabaf2 100644
--- a/tests/frontend/ToolsPage.test.js
+++ b/tests/frontend/ToolsPage.test.js
@@ -24,6 +24,7 @@ describe("ToolsPage.vue", () => {
{ path: "/rnode-flasher", name: "rnode-flasher", component: { template: "div" } },
{ path: "/debug-logs", name: "debug-logs", component: { template: "div" } },
{ path: "/mesh-server", name: "mesh-server", component: { template: "div" } },
+ { path: "/tools/sieve-filters", name: "sieve-filters", component: { template: "div" } },
],
});
@@ -53,7 +54,7 @@ describe("ToolsPage.vue", () => {
it("renders all tool rows", () => {
const wrapper = mountToolsPage();
const toolRows = wrapper.findAll(".tool-row");
- expect(toolRows.length).toBe(19);
+ expect(toolRows.length).toBe(20);
});
it("filters tools based on search query", async () => {
@@ -78,6 +79,6 @@ describe("ToolsPage.vue", () => {
await clearButton.trigger("click");
expect(wrapper.vm.searchQuery).toBe("");
- expect(wrapper.vm.filteredTools.length).toBe(19);
+ expect(wrapper.vm.filteredTools.length).toBe(20);
});
});


──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────